Earlier this year, Github released Atom-Shell, the core of its famous open-source editor Atom , and renamed it to Electron for the special occasion.
Electron, unlike other competitors in the category of Node.js-based desktop applications, brings its own twist to this already well-established market by combining the power of Node.js ( io.js until recent releases) with the Chromium Engine to bring us the best of both server and client-side JavaScript.
Imagine a world where we could build performant, data-driven, cross-platform desktop applications powered by not only the ever-growing repository of NPM modules, but also the entire Bower registry to fulfill all our client-side needs.
Enter Electron .
In this tutorial, we will build a simple password keychain application using Electron, Angular.js and Loki.js , a lightweight and in-memory database with a familiar syntax for MongoDB developers .
The full source code for this application is available here .
This tutorial assumes that:
First things first, we will need to get the Electron binaries in order to test our app locally. We can install it globally and use it as a CLI, or install it locally in our application’s path. I recommend installing it globally, so that way we do not have to do it over and over again for every app we develop.
We will learn later how to package our application for distribution using Gulp. This process involves copying the Electron binaries, and therefore it makes little to no sense to manually install it in our application’s path.
To install the Electron CLI, we can type the following command in our terminal:
$ npm install -g electron-prebuilt
To test the installation, type
electron -h
and it should display the version of the Electron CLI.
At the time this article was written, the version of Electron was
0.31.2
.
Let’s assume the following basic folder structure:
my-app
|- cache/
|- dist/
|- src/
|-- app.js
| gulpfile.js
… where: - cache/ will be used to download the Electron binaries when building the app. - dist/ will contain the generated distribution files. - src/ will contain our source code. - src/app.js will be the entry point of our application.
Next, we will navigate to the
src/
folder in our terminal and create the
package.json
and
bower.json
files for our app:
$ npm init
$ bower init
We will install the necessary packages later on in this tutorial.
Electron distinguishes between two types of processes:
For code clarity, a separate file should be used for each Renderer Process. To define the Main Process for our app, we will open
src/app.js
and include theapp
module to start the app, and thebrowser-window
module to create the various windows of our app (both part of the Electron core), as such:
var app = require('app'),
BrowserWindow = require('browser-window');
When the app is actually started, it fires a
ready
event, which we can bind to. At this point, we can instantiate the main window of our app:
var mainWindow = null;
app.on('ready', function() {
mainWindow = new BrowserWindow({
width: 1024,
height: 768
});
mainWindow.loadUrl('file://' + __dirname + '/windows/main/main.html');
mainWindow.openDevTools();
});
Key points:
BrowserWindow
object.
loadUrl()
method, allowing us to load the contents of an actual HTML file in the current window. The HTML file can either be
local
or
remote
.
openDevTools()
method, allowing us to open an instance of the Chrome Dev Tools in the current window for debugging purposes.
Next, we should organize our code a little. I recommend creating a
windows/
folder in our
src/
folder, and where we can create a subfolder for each window, as such:
my-app
|- src/
|-- windows/
|--- main/
|---- main.controller.js
|---- main.html
|---- main.view.js
… where
main.controller.js
will contain the “server-side” logic of our application, and
main.view.js
will contain the “client-side” logic of our application.
The
main.html
file is simply an HTML5 webpage, so we can simply start it like this:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Password Keychain</title>
</head>
<body>
<h1>Password Keychain</h1>
</body>
</html>
At this point, our app should be ready to run. To test it, we can simply type the following in our terminal, at the root of the
src
folder:
$ electron .
We can automate this process by defining the
start
script of the package.son file.
To build a password keychain application, we need: - A way to add, generate and save passwords. - A convenient way to copy and remove passwords.
A simple form will suffice to insert new passwords. For the sake of demonstrating communication between multiple windows in Electron, start by adding a second window in our application, which will display the “insert” form. Since we will open and close this window multiple times, we should wrap up the logic in a method so that we can simply call it when needed:
function createInsertWindow() {
insertWindow = new BrowserWindow({
width: 640,
height: 480,
show: false
});
insertWindow.loadUrl('file://' + __dirname + '/windows/insert/insert.html');
insertWindow.on('closed',function() {
insertWindow = null;
});
}
Key points:
The idea is to be able to trigger the “insert” window when the end user clicks a button in the “main” window. In order to do this, we will need to send a message from the main window to the Main Process to instruct it to open the insert window. We can achieve this using Electron’s IPC module. There are actually two variants of the IPC module:
Although Electron’s communication channel is mostly uni-directional, it is possible to access the Main Process’ IPC module in a Renderer Process by making use of the remote module. Also, the Main Process can send a message back to the Renderer Process from which the event originated by using the Event.sender.send() method.
To use the IPC module, we just require it like any other NPM module in our Main Process script:
var ipc = require('ipc');
… and then bind to events with the
on()
method:
ipc.on('toggle-insert-view', function() {
if(!insertWindow) {
createInsertWindow();
}
return (!insertWindow.isClosed() && insertWindow.isVisible()) ? insertWindow.hide() : insertWindow.show();
});
Key Points:
closed
state.
Now we actually need to fire that event from the Renderer Process. We will create a new script file called
main.view.js
, and add it to our HTML page like we would with any normal script:
<script src="./main.view.js"></script>
Loading the script file via the HTML
script
tag loads this file in a client-side context. This means that, for example, global variables are available viawindow.<var_name>
. To load a script in a server-side context, we can use therequire()
method directly in our HTML page:require('./main.controller.js');
.
Even though the script is loaded in client-side context, we can still access the IPC module for the Renderer Process in the same way that we can for the Main Process, and then send our event as such:
var ipc = require('ipc');
angular
.module('Utils', [])
.directive('toggleInsertView', function() {
return function(scope, el) {
el.bind('click', function(e) {
e.preventDefault();
ipc.send('toggle-insert-view');
});
};
});
There is also a sendSync() method available, in case we need to send our events synchronously.
Now, all we have left to do to open the “insert” window is to create an HTML button with the matching Angular directive on it:
<div ng-controller="MainCtrl as vm">
<button toggle-insert-view class="mdl-button">
<i class="material-icons">add</i>
</button>
</div>
And add that directive as a dependency of the main window’s Angular controller:
angular
.module('MainWindow', ['Utils'])
.controller('MainCtrl', function() {
var vm = this;
});
To keep things simple, we can just use the NPM
uuid
module to generate unique ID’s that will act as passwords for the purpose of this tutorial. We can install it like any other NPM module, require it in our ‘Utils’ script and then create a simple factory that will return a unique ID:
var uuid = require('uuid');
angular
.module('Utils', [])
...
.factory('Generator', function() {
return {
create: function() {
return uuid.v4();
}
};
})
Now, all we have left to do is create a button in the insert view, and attach a directive to it that will listen to click events on the button and call the create() method:
<!-- in insert.html -->
<button generate-password class="mdl-button">generate</button>
// in Utils.js
angular
.module('Utils', [])
...
.directive('generatePassword', ['Generator', function(Generator) {
return function(scope, el) {
el.bind('click', function(e) {
e.preventDefault();
if(!scope.vm.formData) scope.vm.formData = {};
scope.vm.formData.password = Generator.create();
scope.$apply();
});
};
}])
At this point, we want to store our passwords. The data structure for our password entries is fairly simple:
{
"id": String
"description": String,
"username": String,
"password": String
}
So all we really need is some kind of in-memory database that can optionally sync to file for backup. For this purpose, Loki.js seems like the ideal candidate. It does exactly what we need for the purpose of this application, and offers on top of it the Dynamic Views feature, allowing us to do things similar to MongoDB’s Aggregation module.
Dynamic Views do not offer all the functionality that MongodDB’s Aggregation module does. Please refer to the documentation for more information.
Let’s start by creating a simple HTML form:
<div class="insert" ng-controller="InsertCtrl as vm">
<form name="insertForm" no-validate>
<fieldset ng-disabled="!vm.loaded">
<div class="mdl-textfield">
<input class="mdl-textfield__input" type="text" id="description" ng-model="vm.formData.description" required />
<label class="mdl-textfield__label" for="description">Description...</label>
</div>
<div class="mdl-textfield">
<input class="mdl-textfield__input" type="text" id="username" ng-model="vm.formData.username" />
<label class="mdl-textfield__label" for="username">Username...</label>
</div>
<div class="mdl-textfield">
<input class="mdl-textfield__input" type="password" id="password" ng-model="vm.formData.password" required />
<label class="mdl-textfield__label" for="password">Password...</label>
</div>
<div class="">
<button generate-password class="mdl-button">generate</button>
<button toggle-insert-view class="mdl-button">cancel</button>
<button save-password class="mdl-button" ng-disabled="insertForm.$invalid">save</button>
</div>
</fieldset>
</form>
</div>
And now, let’s add the JavaScript logic to handle posting and saving of the form’s contents:
var loki = require('lokijs'),
path = require('path');
angular
.module('Utils', [])
...
.service('Storage', ['$q', function($q) {
this.db = new loki(path.resolve(__dirname, '../..', 'app.db'));
this.collection = null;
this.loaded = false;
this.init = function() {
var d = $q.defer();
this.reload()
.then(function() {
this.collection = this.db.getCollection('keychain');
d.resolve(this);
}.bind(this))
.catch(function(e) {
// create collection
this.db.addCollection('keychain');
// save and create file
this.db.saveDatabase();
this.collection = this.db.getCollection('keychain');
d.resolve(this);
}.bind(this));
return d.promise;
};
this.addDoc = function(data) {
var d = $q.defer();
if(this.isLoaded() && this.getCollection()) {
this.getCollection().insert(data);
this.db.saveDatabase();
d.resolve(this.getCollection());
} else {
d.reject(new Error('DB NOT READY'));
}
return d.promise;
};
})
.directive('savePassword', ['Storage', function(Storage) {
return function(scope, el) {
el.bind('click', function(e) {
e.preventDefault();
if(scope.vm.formData) {
Storage
.addDoc(scope.vm.formData)
.then(function() {
// reset form & close insert window
scope.vm.formData = {};
ipc.send('toggle-insert-view');
});
}
});
};
}])
Key Points:
getCollection()
method.
insert()
method, allowing us to add a new document to the collection.
saveDatabase()
method.
We now have a simple form allowing us to generate and save new passwords. Let’s go back to the main view to list these entries.
A few things need to happen here:
We can retrieve the list of documents by calling the
getCollection()
method on the Loki object. This method returns an object with a property called
data
, which is simply an array of all the documents in that collection:
this.getCollection = function() {
this.collection = this.db.getCollection('keychain');
return this.collection;
};
this.getDocs = function() {
return (this.getCollection()) ? this.getCollection().data : null;
};
We can then call the getDocs() in our Angular controller and retrieve all the passwords stored in the database, after we initialize it:
angular
.module('MainView', ['Utils'])
.controller('MainCtrl', ['Storage', function(Storage) {
var vm = this;
vm.keychain = null;
Storage
.init()
.then(function(db) {
vm.keychain = db.getDocs();
});
});
A bit of Angular templating, and we have a password list:
<tr ng-repeat="item in vm.keychain track by $index" class="item--{{$index}}">
<td class="mdl-data-table__cell--non-numeric">{{item.description}}</td>
<td>{{item.username || 'n/a'}}</td>
<td>
<span ng-repeat="n in [1,2,3,4,5,6]">•</span>
</td>
<td>
<a href="#" copy-password="{{$index}}">copy</a>
<a href="#" remove-password="{{item}}">remove</a>
</td>
</tr>
A nice added feature would be to refresh the list of passwords after inserting a new one. For this, we can use Electron’s IPC module. As mentioned earlier, the Main Process’ IPC module can be called in a Renderer Process to turn it into a listener process, by using the remote module. Here is an example on how to implement it in
main.view.js
:
var remote = require('remote'),
remoteIpc = remote.require('ipc');
angular
.module('MainView', ['Utils'])
.controller('MainCtrl', ['Storage', function(Storage) {
var vm = this;
vm.keychain = null;
Storage
.init()
.then(function(db) {
vm.keychain = db.getDocs();
remoteIpc.on('update-main-view', function() {
Storage
.reload()
.then(function() {
vm.keychain = db.getDocs();
});
});
});
}]);
Key Points:
require()
method to require the remote IPC module from the Main Process.
on()
method, and bind callback functions to these events.
The insert view will then be in charge of dispatching this event whenever a new document is saved:
Storage
.addDoc(scope.vm.formData)
.then(function() {
// refresh list in main view
ipc.send('update-main-view');
// reset form & close insert window
scope.vm.formData = {};
ipc.send('toggle-insert-view');
});
It is usually not a good idea to display passwords in plain text. Instead, we are going to hide and provide a convenience button allowing the end user to copy the password directly for a specific entry.
Here again, Electron comes to our rescue by providing us with a clipboard module with easy methods to copy and paste not only text content, but also images and HTML code:
var clipboard = require('clipboard');
angular
.module('Utils', [])
...
.directive('copyPassword', [function() {
return function(scope, el, attrs) {
el.bind('click', function(e) {
e.preventDefault();
var text = (scope.vm.keychain[attrs.copyPassword]) ? scope.vm.keychain[attrs.copyPassword].password : '';
// atom's clipboard module
clipboard.clear();
clipboard.writeText(text);
});
};
}]);
Since the generated password will be a simple string, we can use the
writeText()
method to copy the password to the system’s clipboard. We can then update our main view HTML, and add the copy button with the
copy-password
directive on it, providing the index of the array of passwords:
<a href="#" copy-password="{{$index}}">copy</a>
Our end users might also like to be able to delete passwords, in case they become obsolete. To do this, all we need to do is call the
remove()
method on the keychain collection. We need to provide the entire doc to the ‘remove()’ method, as such:
this.removeDoc = function(doc) {
return function() {
var d = $q.defer();
if(this.isLoaded() && this.getCollection()) {
// remove the doc from the collection & persist changes
this.getCollection().remove(doc);
this.db.saveDatabase();
// inform the insert view that the db content has changed
ipc.send('reload-insert-view');
d.resolve(true);
} else {
d.reject(new Error('DB NOT READY'));
}
return d.promise;
}.bind(this);
};
Loki.js documentation states that we can also remove a doc by its id, but it does not seem to be working as expected.
Electron integrates seamlessly with our OS desktop environment to provide a “native” user experience look & feel to our apps. Therefore, Electron comes bundled with a Menu module , dedicated to creating complex desktop menu structures for our app.
The menu module is a vast topic and almost deserves a tutorial of its own. I strongly recommend you read through Electron’s Desktop Environment Integration tutorial to discover all the features of this module.
For the scope of this current tutorial, we will see how to create a custom menu, add a custom command to it, and implement the standard quit command.
Typically, the JavaScript logic for an Electron menu would belong in the main script file of our app, where our Main Process is defined. However, we can abstract it to a separate file, and access the Menu module via the remote module:
var remote = require('remote'),
Menu = remote.require('menu');
To define a simple menu, we will need to use the
buildFromTemplate()
method:
var appMenu = Menu.buildFromTemplate([
{
label: 'Electron',
submenu: [{
label: 'Credits',
click: function() {
alert('Built with Electron & Loki.js.');
}
}]
}
]);
The first item in the array is always used as the “default” menu item.
The value of the
label
property does not matter much for the default menu item. In dev mode it will always displayElectron
. We will see later how to assign a custom name to the default menu item during the build phase.
Finally, we need to assign this custom menu as the default menu for our app with the
setApplicationMenu()
method:
Menu.setApplicationMenu(appMenu);
Electron provides “
accelerators
”, a set of pre-defined strings that map to actual keyboard combinations, e.g.:
Command+A
or
Ctrl+Shift+Z
.
The
Command
accelerator does not work on Windows or Linux. For our password keychain application, we should add aFile
menu item, offering two commands:
...
{
label: 'File',
submenu: [
{
label: 'Create Password',
accelerator: 'CmdOrCtrl+N',
click: function() {
ipc.send('toggle-insert-view');
}
},
{
type: 'separator' // to create a visual separator
},
{
label: 'Quit',
accelerator: 'CmdOrCtrl+Q',
selector: 'terminate:' // OS X only!!!
}
]
}
...
Key Points:
type
property set to
separator
.
CmdOrCtrl
accelerator is compatible with both Mac and PC keyboards
selector
property is OSX-compatible only!
You probably noticed throughout the various code examples references to class names starting with
mdl-
. For the purpose of this tutorial I opted to use the
Material Design Lite
UI framework, but feel free to use any UI framework of your choice.
Anything that we can do with HTML5 can be done in Electron; just keep in mind the growing size of the app’s binaries, and the resulting performance issues that may occur if you use too many third-party libraries.
You made an Electron app, it looks great, you wrote your e2e tests with Selenium and WebDriver , and you are ready to distribute it to the world!
But you still want to personalize it, give it a custom name other than the default “Electron”, and maybe also provide custom application icons for both Mac and PC platforms.
These days, there is a
Gulp
plugin for anything we can think of. All I had to do is type
gulp electron
in Google, and sure enough there is a
gulp-electron
plugin!
This plugin is fairly easy to use as long as the folder structure detailed at the beginning of this tutorial was maintained. If not, you might have to move things around a bit.
This plugin can be installed like any other Gulp plugin:
$ npm install gulp-electron --save-dev
And then we can define our Gulp task as such:
var gulp = require('gulp'),
electron = require('gulp-electron'),
info = require('./src/package.json');
gulp.task('electron', function() {
gulp.src("")
.pipe(electron({
src: './src',
packageJson: info,
release: './dist',
cache: './cache',
version: 'v0.31.2',
packaging: true,
platforms: ['win32-ia32', 'darwin-x64'],
platformResources: {
darwin: {
CFBundleDisplayName: info.name,
CFBundleIdentifier: info.bundle,
CFBundleName: info.name,
CFBundleVersion: info.version
},
win: {
"version-string": info.version,
"file-version": info.version,
"product-version": info.version
}
}
}))
.pipe(gulp.dest(""));
});
Key Points:
src/
folder cannot be the same as the folder where the Gulpfile.js is, nor the same folder as the distribution folder.
platforms
array.
cache
folder, where the Electron binaries will be download so they can be packaged with our app.
packageJson
property.
packaging
property, allowing us to also create zip archives of the generated apps.
One of the
platformResources
properties is the
icon
property, allowing us to define a custom icon for our app:
"icon": "keychain.ico"
OS X requires icons with the
.icns
file extension. There are multiple online tools allowing us to convert.png
files into.ico
and.icns
for free.
In this article we have only scratched the surface of what Electron can actually do. Think of great apps like Atom or Slack as a source of inspiration where you can go with this tool.
I hope you found this tutorial useful, please feel free to leave your comments and share your experiences with Electron!